/*
 * Copyright 2013-2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License").
 * You may not use this file except in compliance with the License.
 * A copy of the License is located at
 *
 *  http://aws.amazon.com/apache2.0
 *
 * or in the "license" file accompanying this file. This file is distributed
 * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
 * express or implied. See the License for the specific language governing
 * permissions and limitations under the License.
 */
package com.ibm.cloud.objectstorage.util;

import static com.ibm.cloud.objectstorage.SDKGlobalConfiguration.EC2_METADATA_SERVICE_OVERRIDE_SYSTEM_PROPERTY;
import static com.ibm.cloud.objectstorage.SDKGlobalConfiguration.EC2_METADATA_SERVICE_OVERRIDE_ENV_VAR;

import com.ibm.cloud.objectstorage.AmazonClientException;
import com.ibm.cloud.objectstorage.SDKGlobalConfiguration;
import com.ibm.cloud.objectstorage.SdkClientException;
import com.ibm.cloud.objectstorage.internal.InstanceMetadataServiceResourceFetcher;
import com.ibm.cloud.objectstorage.retry.internal.CredentialsEndpointRetryParameters;
import com.ibm.cloud.objectstorage.retry.internal.CredentialsEndpointRetryPolicy;
import com.ibm.cloud.objectstorage.util.json.Jackson;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import java.net.URI;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/**
 * Utility class for retrieving Amazon EC2 instance metadata.<br>
 * You can use the data to build more generic AMIs that can be modified by
 * configuration files supplied at launch time. For example, if you run web
 * servers for various small businesses, they can all use the same AMI and
 * retrieve their content from the Amazon S3 bucket you specify at launch. To
 * add a new customer at any time, simply create a bucket for the customer, add
 * their content, and launch your AMI.<br>
 *
 * <p>
 * You can disable the use of the EC2 Instance meta data service by either setting the
 * {@link SDKGlobalConfiguration#AWS_EC2_METADATA_DISABLED_ENV_VAR} or
 * {@link SDKGlobalConfiguration#AWS_EC2_METADATA_DISABLED_SYSTEM_PROPERTY} to 'true'(not case sensitive).
 *
 * More information about Amazon EC2 Metadata
 *
 * @see <a
 *      href="http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/AESDG-chapter-instancedata.html">Amazon
 *      EC2 User Guide: Instance Metadata</a>
 */
public class EC2MetadataUtils {

    private static final String REGION = "region";
    private static final String INSTANCE_IDENTITY_DOCUMENT = "instance-identity/document";
    private static final String INSTANCE_IDENTITY_SIGNATURE = "instance-identity/signature";
    private static final String EC2_METADATA_ROOT = "/latest/meta-data";
    private static final String EC2_USERDATA_ROOT = "/latest/user-data/";
    private static final String EC2_DYNAMICDATA_ROOT = "/latest/dynamic/";

    /** Default endpoint for the Amazon EC2 Instance Metadata Service. */
    private static final String EC2_METADATA_SERVICE_URL = "http://169.254.169.254";

    /** Default resource path for credentials in the Amazon EC2 Instance Metadata Service. */
    public static final String SECURITY_CREDENTIALS_RESOURCE = "/latest/meta-data/iam/security-credentials/";

    private static final int DEFAULT_QUERY_RETRIES = 3;
    private static final int MINIMUM_RETRY_WAIT_TIME_MILLISECONDS = 250;
    private static Map<String, String> cache = new ConcurrentHashMap<String, String>();

    private static final ObjectMapper mapper = new ObjectMapper();
    static {
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        // If a customer is using an older Jackson version than 2.12.x, it will throw an error
        // and an upgrade to a newer version above 2.16.x is recommended
        mapper.setPropertyNamingStrategy(PropertyNamingStrategies.UPPER_CAMEL_CASE);
    }

    private static final Log log = LogFactory.getLog(EC2MetadataUtils.class);

    /**
     * Get the AMI ID used to launch the instance.
     */
    public static String getAmiId() {
        return fetchData(EC2_METADATA_ROOT + "/ami-id");
    }

    /**
     * Get the index of this instance in the reservation.
     */
    public static String getAmiLaunchIndex() {
        return fetchData(EC2_METADATA_ROOT + "/ami-launch-index");
    }

    /**
     * Get the manifest path of the AMI with which the instance was launched.
     */
    public static String getAmiManifestPath() {
        return fetchData(EC2_METADATA_ROOT + "/ami-manifest-path");
    }

    /**
     * Get the list of AMI IDs of any instances that were rebundled to created
     * this AMI. Will only exist if the AMI manifest file contained an
     * ancestor-amis key.
     */
    public static List<String> getAncestorAmiIds() {
        return getItems(EC2_METADATA_ROOT + "/ancestor-ami-ids");
    }

    /**
     * Notifies the instance that it should reboot in preparation for bundling.
     * Valid values: none | shutdown | bundle-pending.
     */
    public static String getInstanceAction() {
        return fetchData(EC2_METADATA_ROOT + "/instance-action");
    }

    /**
     * Get the ID of this instance.
     */
    public static String getInstanceId() {
        return fetchData(EC2_METADATA_ROOT + "/instance-id");
    }

    /**
     * Get the type of the instance.
     */
    public static String getInstanceType() {
        return fetchData(EC2_METADATA_ROOT + "/instance-type");
    }

    /**
     * Get the local hostname of the instance. In cases where multiple network
     * interfaces are present, this refers to the eth0 device (the device for
     * which device-number is 0).
     */
    public static String getLocalHostName() {
        return fetchData(EC2_METADATA_ROOT + "/local-hostname");
    }

    /**
     * Get the MAC address of the instance. In cases where multiple network
     * interfaces are present, this refers to the eth0 device (the device for
     * which device-number is 0).
     */
    public static String getMacAddress() {
        return fetchData(EC2_METADATA_ROOT + "/mac");
    }

    /**
     * Get the private IP address of the instance. In cases where multiple
     * network interfaces are present, this refers to the eth0 device (the
     * device for which device-number is 0).
     */
    public static String getPrivateIpAddress() {
        return fetchData(EC2_METADATA_ROOT + "/local-ipv4");
    }

    /**
     * Get the Availability Zone in which the instance launched.
     */
    public static String getAvailabilityZone() {
        return fetchData(EC2_METADATA_ROOT + "/placement/availability-zone");
    }

    /**
     * Get the list of product codes associated with the instance, if any.
     */
    public static List<String> getProductCodes() {
        return getItems(EC2_METADATA_ROOT + "/product-codes");
    }

    /**
     * Get the public key. Only available if supplied at instance launch time.
     */
    public static String getPublicKey() {
        return fetchData(EC2_METADATA_ROOT + "/public-keys/0/openssh-key");
    }

    /**
     * Get the ID of the RAM disk specified at launch time, if applicable.
     */
    public static String getRamdiskId() {
        return fetchData(EC2_METADATA_ROOT + "/ramdisk-id");
    }

    /**
     * Get the ID of the reservation.
     */
    public static String getReservationId() {
        return fetchData(EC2_METADATA_ROOT + "/reservation-id");
    }

    /**
     * Get the list of names of the security groups applied to the instance.
     */
    public static List<String> getSecurityGroups() {
        return getItems(EC2_METADATA_ROOT + "/security-groups");
    }

    /**
     * Get information about the last time the instance profile was updated,
     * including the instance's LastUpdated date, InstanceProfileArn, and
     * InstanceProfileId.
     */
    public static IAMInfo getIAMInstanceProfileInfo() {
        String json = getData(EC2_METADATA_ROOT + "/iam/info");
        if (null == json) {
            return null;
        }

        try {

            return mapper.readValue(json, IAMInfo.class);

        } catch (Exception e) {
            log.warn("Unable to parse IAM Instance profile info (" + json
                    + "): " + e.getMessage(), e);
            return null;
        }
    }

    /**
     * The instance info is only guaranteed to be a JSON document per
     * http://docs
     * .aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html
     * <p>
     * This method is only a best attempt to capture the instance info as a
     * typed object.
     * <p>
     * Get an InstanceInfo object with dynamic information about this instance.
     */
    public static InstanceInfo getInstanceInfo() {
        return doGetInstanceInfo(getData(
                EC2_DYNAMICDATA_ROOT + INSTANCE_IDENTITY_DOCUMENT));
    }

    /**
     * Get the signature of the instance.
     */
    public static String getInstanceSignature() {
        return fetchData(EC2_DYNAMICDATA_ROOT + INSTANCE_IDENTITY_SIGNATURE);
    }

    static InstanceInfo doGetInstanceInfo(String json) {
        if (null != json) {
            try {
                InstanceInfo instanceInfo = Jackson.fromJsonString(json,
                        InstanceInfo.class);
                return instanceInfo;
            } catch (Exception e) {
                log.warn("Unable to parse dynamic EC2 instance info (" + json
                        + ") : " + e.getMessage(), e);
            }
        }
        return null;
    }

    /**
     * Returns the current region of this running EC2 instance; or null if
     * it is unable to do so. The method avoids interpreting other parts of the
     * instance info JSON document to minimize potential failure.
     * <p>
     * The instance info is only guaranteed to be a JSON document per
     * http://docs
     * .aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html
     */
    public static String getEC2InstanceRegion() {
        return doGetEC2InstanceRegion(getData(
                EC2_DYNAMICDATA_ROOT + INSTANCE_IDENTITY_DOCUMENT));
    }

    static String doGetEC2InstanceRegion(final String json) {
        if (null != json) {
            try {
                JsonNode node = mapper.readTree(json.getBytes(StringUtils.UTF8));
                JsonNode region = node.findValue(REGION);
                return region.asText();
            } catch (Exception e) {
                log.warn("Unable to parse EC2 instance info (" + json
                        + ") : " + e.getMessage(), e);
            }
        }
        return null;
    }

    /**
     * Returns the temporary security credentials (AccessKeyId, SecretAccessKey,
     * SessionToken, and Expiration) associated with the IAM roles on the
     * instance.
     */
    public static Map<String, IAMSecurityCredential> getIAMSecurityCredentials() {
        Map<String, IAMSecurityCredential> credentialsInfoMap = new HashMap<String, IAMSecurityCredential>();

        List<String> credentials = getItems(EC2_METADATA_ROOT + "/iam/security-credentials");

        if (credentials != null) {
            for (String credential : credentials) {
                String json = getData(EC2_METADATA_ROOT + "/iam/security-credentials/" + credential);
                try {
                    IAMSecurityCredential credentialInfo = mapper.readValue(json, IAMSecurityCredential.class);
                    credentialsInfoMap.put(credential, credentialInfo);
                } catch (Exception e) {
                    log.warn("Unable to process the credential (" + credential + "). " + e.getMessage(), e);
                }
            }
        }
        return credentialsInfoMap;
    }

    /**
     * Get the virtual devices associated with the ami, root, ebs, and swap.
     */
    public static Map<String, String> getBlockDeviceMapping() {
        Map<String, String> blockDeviceMapping = new HashMap<String, String>();

        List<String> devices = getItems(EC2_METADATA_ROOT + "/block-device-mapping");
        if (devices != null) {
            for (String device : devices) {
                blockDeviceMapping.put(device, getData(EC2_METADATA_ROOT
                                                       + "/block-device-mapping/" + device));
            }
        }
        return blockDeviceMapping;
    }

    /**
     * Get the list of network interfaces on the instance.
     */
    public static List<NetworkInterface> getNetworkInterfaces() {
        List<NetworkInterface> networkInterfaces = new LinkedList<NetworkInterface>();

        List<String> macs = getItems(EC2_METADATA_ROOT + "/network/interfaces/macs/");
        if (macs != null) {
            for (String mac : macs) {
                String key = mac.trim();
                if (key.endsWith("/")) {
                    key = key.substring(0, key.length() - 1);
                }
                networkInterfaces.add(new NetworkInterface(key));
            }
        }
        return networkInterfaces;
    }

    /**
     * Get the metadata sent to the instance
     */
    public static String getUserData() {
        return getData(EC2_USERDATA_ROOT);
    }

    public static String getData(String path) {
        return getData(path, DEFAULT_QUERY_RETRIES);
    }

    public static String getData(String path, int tries) {
        List<String> items = getItems(path, tries, true);
        if (null != items && items.size() > 0)
            return items.get(0);
        return null;
    }

    /**
     * @param path Path to query.
     * @return List of items for given path or null if path does not exist.
     */
    public static List<String> getItems(String path) {
        return getItems(path, DEFAULT_QUERY_RETRIES, false);
    }

    /**
     * @param path  Path to query.
     * @param tries Number of attempts to query EC2 metadata service for items.
     * @return List of items for given path or null if path does not exist.
     */
    public static List<String> getItems(String path, int tries) {
        return getItems(path, tries, false);
    }

    private static List<String> getItems(String path, int tries, boolean slurp) {
        if (tries == 0)
            throw new SdkClientException(
                    "Unable to contact EC2 metadata service.");

        List<String> items;
        try {
            String hostAddress = getHostAddressForEC2MetadataService();
            String response = InstanceMetadataServiceResourceFetcher.getInstance().readResource(new URI(hostAddress + path), EC2MetadataUtilsRetryPolicy.INSTANCE);
            if (slurp)
                items = Collections.singletonList(response);
            else
                items = Arrays.asList(response.split("\n"));
            return items;
        } catch (Exception ace) {
            log.warn("Unable to retrieve the requested metadata (" + path + "). " + ace.getMessage(), ace);
            return null;
        }
    }

    private static String fetchData(String path) {
        return fetchData(path, false);
    }

    private static String fetchData(String path, boolean force) {
        try {
            if (force || !cache.containsKey(path))
                cache.put(path, getData(path));
            return cache.get(path);
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * Returns the host address of the Amazon EC2 Instance Metadata Service.
     */
    public static String getHostAddressForEC2MetadataService() {
        String host = System.getProperty(EC2_METADATA_SERVICE_OVERRIDE_SYSTEM_PROPERTY);
        if (host == null) {
            host = System.getenv(EC2_METADATA_SERVICE_OVERRIDE_ENV_VAR);
        }
        return host != null ? host : EC2_METADATA_SERVICE_URL;
    }

    /**
     * Information about the last time the instance profile was updated,
     * including the instance's LastUpdated date, InstanceProfileArn, and
     * InstanceProfileId.
     */
    public static class IAMInfo {
        public String code;
        public String message;
        public String lastUpdated;
        public String instanceProfileArn;
        public String instanceProfileId;
    }

    /**
     * The temporary security credentials (AccessKeyId, SecretAccessKey,
     * SessionToken, and Expiration) associated with the IAM role.
     */
    public static class IAMSecurityCredential {
        public String code;
        public String message;
        public String lastUpdated;
        public String type;
        public String accessKeyId;
        public String secretAccessKey;
        public String token;
        public String expiration;

        /**
         * @deprecated because it is spelled incorrectly
         * @see #accessKeyId
         */
        @Deprecated
        public String secretAcessKey;
    }

    /**
     * This POJO is a best attempt to capture the instance info which is only
     * guaranteed to be a JSON document per
     * http://docs.aws.amazon.com/AWSEC2/latest
     * /UserGuide/ec2-instance-metadata.html
     *
     * Instance info includes dynamic information about the current instance
     * such as region, instanceId, private IP address, etc.
     */
    public static class InstanceInfo {
        private final String pendingTime;
        private final String instanceType;
        private final String imageId;
        private final String instanceId;
        private final String[] billingProducts;
        private final String architecture;
        private final String accountId;
        private final String kernelId;
        private final String ramdiskId;
        private final String region;
        private final String version;
        private final String availabilityZone;
        private final String privateIp;
        private final String[] devpayProductCodes;

        @JsonCreator
        public InstanceInfo(
                @JsonProperty(value = "pendingTime", required = true) String pendingTime,
                @JsonProperty(value = "instanceType", required = true) String instanceType,
                @JsonProperty(value = "imageId", required = true) String imageId,
                @JsonProperty(value = "instanceId", required = true) String instanceId,
                @JsonProperty(value = "billingProducts", required = false) String[] billingProducts,
                @JsonProperty(value = "architecture", required = true) String architecture,
                @JsonProperty(value = "accountId", required = true) String accountId,
                @JsonProperty(value = "kernelId", required = true) String kernelId,
                @JsonProperty(value = "ramdiskId", required = false) String ramdiskId,
                @JsonProperty(value = REGION, required = true) String region,
                @JsonProperty(value = "version", required = true) String version,
                @JsonProperty(value = "availabilityZone", required = true) String availabilityZone,
                @JsonProperty(value = "privateIp", required = true) String privateIp,
                @JsonProperty(value = "devpayProductCodes", required = false) String[] devpayProductCodes) {
            this.pendingTime = pendingTime;
            this.instanceType = instanceType;
            this.imageId = imageId;
            this.instanceId = instanceId;
            this.billingProducts = billingProducts == null
                    ? null : billingProducts.clone();
            this.architecture = architecture;
            this.accountId = accountId;
            this.kernelId = kernelId;
            this.ramdiskId = ramdiskId;
            this.region = region;
            this.version = version;
            this.availabilityZone = availabilityZone;
            this.privateIp = privateIp;
            this.devpayProductCodes = devpayProductCodes == null
                    ? null : devpayProductCodes.clone();
        }

        public String getPendingTime() {
            return pendingTime;
        }

        public String getInstanceType() {
            return instanceType;
        }

        public String getImageId() {
            return imageId;
        }

        public String getInstanceId() {
            return instanceId;
        }

        public String[] getBillingProducts() {
            return billingProducts == null ? null : billingProducts.clone();
        }

        public String getArchitecture() {
            return architecture;
        }

        public String getAccountId() {
            return accountId;
        }

        public String getKernelId() {
            return kernelId;
        }

        public String getRamdiskId() {
            return ramdiskId;
        }

        public String getRegion() {
            return region;
        }

        public String getVersion() {
            return version;
        }

        public String getAvailabilityZone() {
            return availabilityZone;
        }

        public String getPrivateIp() {
            return privateIp;
        }

        public String[] getDevpayProductCodes() {
            return devpayProductCodes == null ? null : devpayProductCodes.clone();
        }
    }

    /**
     * All of the metadata associated with a network interface on the instance.
     */
    public static class NetworkInterface {
        private String path;
        private String mac;

        private List<String> availableKeys;
        private Map<String, String> data = new HashMap<String, String>();

        public NetworkInterface(String macAddress) {
            mac = macAddress;
            path = "/network/interfaces/macs/" + mac + "/";
        }

        /**
         * The interface's Media Acess Control (mac) address
         */
        public String getMacAddress() {
            return mac;
        }

        /**
         * The ID of the owner of the network interface.<br>
         * In multiple-interface environments, an interface can be attached by a
         * third party, such as Elastic Load Balancing. Traffic on an interface
         * is always billed to the interface owner.
         */
        public String getOwnerId() {
            return getData("owner-id");
        }

        /**
         * The interface's profile.
         */
        public String getProfile() {
            return getData("profile");
        }

        /**
         * The interface's local hostname.
         */
        public String getHostname() {
            return getData("local-hostname");
        }

        /**
         * The private IP addresses associated with the interface.
         */
        public List<String> getLocalIPv4s() {
            return getItems("local-ipv4s");
        }

        /**
         * The interface's public hostname.
         */
        public String getPublicHostname() {
            return getData("public-hostname");
        }

        /**
         * The elastic IP addresses associated with the interface.<br>
         * There may be multiple IP addresses on an instance.
         */
        public List<String> getPublicIPv4s() {
            return getItems("public-ipv4s");
        }

        /**
         * Security groups to which the network interface belongs.
         */
        public List<String> getSecurityGroups() {
            return getItems("security-groups");
        }

        /**
         * IDs of the security groups to which the network interface belongs.
         * Returned only for Amazon EC2 instances launched into a VPC.
         */
        public List<String> getSecurityGroupIds() {
            return getItems("security-group-ids");
        }

        /**
         * The CIDR block of the Amazon EC2-VPC subnet in which the interface
         * resides.<br>
         * Returned only for Amazon EC2 instances launched into a VPC.
         */
        public String getSubnetIPv4CidrBlock() {
            return getData("subnet-ipv4-cidr-block");
        }

        /**
         * ID of the subnet in which the interface resides.<br>
         * Returned only for Amazon EC2 instances launched into a VPC.
         */
         public String getSubnetId() {
             return getData("subnet-id");
         }

        /**
         * The CIDR block of the Amazon EC2-VPC in which the interface
         * resides.<br>
         * Returned only for Amazon EC2 instances launched into a VPC.
         */
         public String getVpcIPv4CidrBlock() {
             return getData("vpc-ipv4-cidr-block");
         }

        /**
         * ID of the Amazon EC2-VPC in which the interface resides.<br>
         * Returned only for Amazon EC2 instances launched into a VPC.
         */
        public String getVpcId() {
            return getData("vpc-id");
        }

        /**
         * Get the private IPv4 address(es) that are associated with the
         * public-ip address and assigned to that interface.
         *
         * @param publicIp
         *            The public IP address
         * @return Private IPv4 address(es) associated with the public IP
         *         address.
         */
        public List<String> getIPv4Association(String publicIp) {
            return EC2MetadataUtils.getItems(EC2_METADATA_ROOT + path
                    + "ipv4-associations/" + publicIp);
        }

        private String getData(String key) {
            if (data.containsKey(key))
                return data.get(key);

            // Since the keys are variable, cache a list of which ones are available to prevent unnecessary trips to the service.
            if (null == availableKeys) {
                availableKeys = EC2MetadataUtils.getItems(EC2_METADATA_ROOT + path);
            }

            if (availableKeys != null && availableKeys.contains(key)) {
                data.put(key, EC2MetadataUtils.getData(EC2_METADATA_ROOT + path + key));
                return data.get(key);
            } else {
                return null;
            }
        }

        private List<String> getItems(String key) {
            if (null == availableKeys) {
                availableKeys = EC2MetadataUtils.getItems(EC2_METADATA_ROOT + path);
            }

            if (availableKeys != null && availableKeys.contains(key)) {
                return EC2MetadataUtils.getItems(EC2_METADATA_ROOT + path + key);
            } else {
                return new LinkedList<String>();
            }
        }
    }

    private static final class EC2MetadataUtilsRetryPolicy implements CredentialsEndpointRetryPolicy {

        private static final EC2MetadataUtilsRetryPolicy INSTANCE = new EC2MetadataUtilsRetryPolicy();

        @Override
        public boolean shouldRetry(int retriesAttempted, CredentialsEndpointRetryParameters retryParams) {
            if (retriesAttempted >= DEFAULT_QUERY_RETRIES) {
                return false;
            }

            if (retryParams.getException() instanceof AmazonClientException) {
                return false;
            }

            // Retry on any other exceptions
            int pause = (int) (Math.pow(2, DEFAULT_QUERY_RETRIES - retriesAttempted) * MINIMUM_RETRY_WAIT_TIME_MILLISECONDS);
            try {
                Thread.sleep(Math.max(pause, MINIMUM_RETRY_WAIT_TIME_MILLISECONDS));
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }

            return true;
        }
    }
}
